Відкрийте для себе розширені патерни type-safe валідації форм для створення надійних, безпомилкових додатків. Цей посібник охоплює техніки для глобальних розробників.
Опанування Type-Safe обробки форм: Посібник з патернів валідації вхідних даних
У світі веб-розробки форми є критичним інтерфейсом між користувачами та нашими додатками. Вони є шлюзами для реєстрації, надсилання даних, конфігурації та незліченних інших взаємодій. Проте, для такого фундаментального компонента, обробка вхідних даних форми залишається сумнозвісним джерелом помилок, вразливостей безпеки та розчаровуючих користувацьких досвідів. Ми всі там були: форма, яка аварійно завершує роботу через несподіваний вхід, серверна частина, яка виходить з ладу через невідповідність даних, або користувач, який дивується, чому його подання було відхилено. Корінь цього хаосу часто лежить в одній, поширеній проблемі: розрив між формою даних, логікою валідації та станом програми.
Саме тут type safety революціонізує гру. Виходячи за рамки простих перевірок під час виконання та охоплюючи підхід, орієнтований на типи, ми можемо створювати форми, які не просто функціональні, але й доказово правильні, надійні та зручні в обслуговуванні. Ця стаття є глибоким зануренням у сучасні патерни для type-safe обробки форм. Ми дослідимо, як створити єдине джерело правди для форми та правил ваших даних, усуваючи надмірність і гарантуючи, що ваші frontend типи та логіка валідації ніколи не будуть розсинхронізовані. Незалежно від того, чи працюєте ви з React, Vue, Svelte чи будь-яким іншим сучасним фреймворком, ці принципи дадуть вам змогу писати чистіший, безпечніший і більш передбачуваний код форми для глобальної бази користувачів.
Крихкість традиційної валідації форм
Перш ніж ми дослідимо рішення, важливо зрозуміти обмеження традиційних підходів. Протягом багатьох років розробники обробляли валідацію форм, зшиваючи разом розрізнені частини логіки, що часто призводило до крихкої та схильної до помилок системи. Давайте розберемо цю традиційну модель.
Три силоси логіки форми
У типовій, не type-safe установці логіка форми фрагментована в трьох різних областях:
- Визначення типу (The 'What'): Це наш контракт з компілятором. У TypeScript це `interface` або `type` alias, який описує очікувану форму даних форми.
// The intended shape of our data interface UserProfile { username: string; email: string; age?: number; // Optional age website: string; } - Логіка валідації (The 'How'): Це окремий набір правил, зазвичай функція або сукупність умовних перевірок, які виконуються під час виконання, щоб забезпечити обмеження на вхідні дані користувача.
// A separate function to validate the data function validateProfile(data) { const errors = {}; if (!data.username || data.username.length < 3) { errors.username = 'Username must be at least 3 characters.'; } if (!data.email || !/\S+@\S+\.\S+/.test(data.email)) { errors.email = 'Please provide a valid email address.'; } if (data.age && (isNaN(data.age) || data.age < 18)) { errors.age = 'You must be at least 18 years old.'; } // This doesn't even check if website is a valid URL! return errors; } - Серверний DTO/Model (The 'Backend What'): Серверна частина має власне представлення даних, часто Data Transfer Object (DTO) або модель бази даних. Це ще одне визначення тієї самої структури даних, часто написане іншою мовою або фреймворком.
Невідворотні наслідки фрагментації
Це розділення створює систему, схильну до збоїв. Компілятор може перевірити, чи передаєте ви об’єкт, який схожий на `UserProfile` вашій функції валідації, але він не має способу дізнатися, чи функція `validateProfile` фактично застосовує правила, що маються на увазі типом `UserProfile`. Це призводить до кількох критичних проблем:
- Logic and Type Drift: Найпоширеніша проблема. Розробник оновлює інтерфейс `UserProfile`, щоб зробити `age` обов’язковим полем, але забуває оновити функцію `validateProfile`. Код все ще компілюється, але тепер ваш додаток може надсилати недійсні дані. Тип каже одне, але логіка часу виконання робить інше.
- Дублювання зусиль: Логіку валідації для інтерфейсу часто потрібно повторно реалізувати на серверній частині, щоб забезпечити цілісність даних. Це порушує принцип Don't Repeat Yourself (DRY) і подвоює тягар обслуговування. Зміна вимог означає оновлення коду принаймні у двох місцях.
- Слабкі гарантії: Тип `UserProfile` визначає `age` як `number`, але вхідні дані HTML-форми надають рядки. Логіка валідації повинна пам'ятати про обробку цього перетворення. Якщо цього не зробити, ви можете надсилати `"25"` у свій API замість `25`, що призведе до тонких помилок, які важко відстежити.
- Поганий досвід розробника: Без уніфікованої системи розробникам постійно доводиться перехресно перевіряти кілька файлів, щоб зрозуміти поведінку форми. Цей розумовий наклад уповільнює розробку та збільшує ймовірність помилок.
Зміна парадигми: валідація за схемою
Рішенням цієї фрагментації є потужна зміна парадигми: замість визначення типів і правил валідації окремо, ми визначаємо єдину схему валідації, яка служить остаточним джерелом правди. З цієї схеми ми можемо потім вивести наші статичні типи.
Що таке схема валідації?
Схема валідації — це декларативний об’єкт, який визначає форму, типи даних і обмеження ваших даних. Ви не пишете оператори `if`; ви описуєте, якими повинні бути дані. Бібліотеки, такі як Zod, Valibot, Yup та Joi, чудово справляються з цим.
У решті цієї статті ми будемо використовувати Zod для наших прикладів через чудову підтримку TypeScript, зрозумілий API та зростаючу популярність. Однак обговорювані патерни застосовні й до інших сучасних бібліотек валідації.
Давайте перепишемо наш приклад `UserProfile` за допомогою Zod:
import { z } from 'zod';
// The single source of truth
const UserProfileSchema = z.object({
username: z.string().min(3, { message: "Username must be at least 3 characters." }),
email: z.string().email({ message: "Invalid email address." }),
age: z.number().min(18, { message: "You must be at least 18." }).optional(),
website: z.string().url({ message: "Please enter a valid URL." }),
});
// Infer the TypeScript type directly from the schema
type UserProfile = z.infer<typeof UserProfileSchema>;
/*
This generated 'UserProfile' type is equivalent to:
type UserProfile = {
username: string;
email: string;
age?: number | undefined;
website: string;
}
It's always in sync with the validation rules!
*/
Переваги підходу за схемою
- Single Source of Truth (SSOT): `UserProfileSchema` тепер є єдиним місцем, де ми визначаємо наш контракт даних. Будь-яка зміна тут автоматично відображається як у нашій логіці валідації, так і в наших типах TypeScript.
- Гарантована узгодженість: Тепер неможливо, щоб тип і логіка валідації розійшлися. Утиліта `z.infer` гарантує, що наші статичні типи є ідеальним дзеркалом наших правил валідації під час виконання. Якщо ви видалите `.optional()` з `age`, тип TypeScript `UserProfile` одразу відобразить, що `age` є обов’язковим `number`.
- Збагачений досвід розробника: Ви отримуєте чудове автозаповнення та перевірку типів у вашому додатку. Коли ви отримуєте доступ до даних після успішної валідації, TypeScript знає точну форму та тип кожного поля.
- Читабельність і зручність обслуговування: Схеми є декларативними та легкими для читання. Новий розробник може подивитися на схему та одразу зрозуміти вимоги до даних, не розшифровуючи складний імперативний код.
Основні патерни валідації зі схемами
Тепер, коли ми розуміємо «чому», давайте заглибимося в «як». Ось кілька важливих патернів для створення надійних форм за допомогою підходу за схемою.
Патерн 1: Базова та складна валідація полів
Бібліотеки схем надають широкий набір вбудованих примітивів валідації, які можна об’єднати в ланцюжок, щоб створити точні правила.
import { z } from 'zod';
const RegistrationSchema = z.object({
// A required string with min/max length
fullName: z.string().min(2, 'Full name is too short').max(100, 'Full name is too long'),
// A number that must be an integer and within a specific range
invitationCode: z.number().int().positive('Code must be a positive number'),
// A boolean that must be true (for checkboxes like "I agree to the terms")
agreedToTerms: z.literal(true, {
errorMap: () => ({ message: 'You must agree to the terms and conditions.' })
}),
// An enum for a select dropdown
accountType: z.enum(['personal', 'business']),
// An optional field
bio: z.string().max(500).optional(),
});
type RegistrationForm = z.infer<typeof RegistrationSchema>;
Ця єдина схема визначає повний набір правил. Повідомлення, пов’язані з кожним правилом валідації, надають чіткий, зручний для користувача зворотний зв’язок. Зауважте, як ми можемо обробляти різні типи вхідних даних — текст, числа, логічні значення та спадні списки — все в межах тієї самої декларативної структури.
Патерн 2: Обробка вкладених об’єктів і масивів
Реальні форми рідко бувають плоскими. Схеми полегшують обробку складних, вкладених структур даних, таких як адреси або масиви елементів, таких як навички чи номери телефонів.
import { z } from 'zod';
const AddressSchema = z.object({
street: z.string().min(5, 'Street address is required.'),
city: z.string().min(2, 'City is required.'),
postalCode: z.string().regex(/^[0-9]{5}(?:-[0-9]{4})?$/, 'Invalid postal code format.'),
country: z.string().length(2, 'Use the 2-letter country code.'),
});
const SkillSchema = z.object({
id: z.string().uuid(),
name: z.string(),
proficiency: z.enum(['beginner', 'intermediate', 'expert']),
});
const CompanyProfileSchema = z.object({
companyName: z.string().min(1),
contactEmail: z.string().email(),
billingAddress: AddressSchema, // Nesting the address schema
shippingAddress: AddressSchema.optional(), // Nesting can also be optional
skillsNeeded: z.array(SkillSchema).min(1, 'Please list at least one required skill.'),
});
type CompanyProfile = z.infer<typeof CompanyProfileSchema>;
У цьому прикладі ми склали схеми. `CompanyProfileSchema` повторно використовує `AddressSchema` як для платіжної, так і для адреси доставки. Він також визначає `skillsNeeded` як масив, де кожен елемент має відповідати `SkillSchema`. Виведений тип `CompanyProfile` буде ідеально структурований з усіма вкладеними об’єктами та масивами з правильно вказаними типами.
Патерн 3: Розширена умовна та міжпольова валідація
Тут валідація на основі схеми справді сяє, дозволяючи обробляти динамічні форми, де вимога одного поля залежить від значення іншого.
Умовна логіка з `discriminatedUnion`
Уявіть собі форму, де користувач може вибрати спосіб сповіщення. Якщо вони виберуть «Електронна пошта», має з’явитися поле електронної пошти та бути обов’язковим. Якщо вони виберуть «SMS», поле номера телефону має стати обов’язковим.
import { z } from 'zod';
const NotificationSchema = z.discriminatedUnion('method', [
z.object({
method: z.literal('email'),
emailAddress: z.string().email(),
}),
z.object({
method: z.literal('sms'),
phoneNumber: z.string().min(10, 'Please provide a valid phone number.'),
}),
z.object({
method: z.literal('none'),
}),
]);
type NotificationPreferences = z.infer<typeof NotificationSchema>;
// Example valid data:
// const byEmail: NotificationPreferences = { method: 'email', emailAddress: 'test@example.com' };
// const bySms: NotificationPreferences = { method: 'sms', phoneNumber: '1234567890' };
// Example invalid data (will fail validation):
// const invalid = { method: 'email', phoneNumber: '1234567890' };
`discriminatedUnion` ідеально підходить для цього. Він дивиться на поле `method` і, залежно від його значення, застосовує правильну відповідну схему. Отриманий тип TypeScript є чудовим типом об’єднання, який дозволяє безпечно перевірити `method` і знати, які інші поля доступні.
Перехресна валідація полів за допомогою `superRefine`
Класична вимога до форми – підтвердження пароля. Поля `password` і `confirmPassword` повинні збігатися. Це неможливо перевірити в одному полі; потрібне порівняння двох. `.superRefine()` (або `.refine()` на об’єкті) Zod є інструментом для цієї роботи.
import { z } from 'zod';
const PasswordChangeSchema = z.object({
password: z.string().min(8, 'Password must be at least 8 characters long.'),
confirmPassword: z.string(),
})
.superRefine(({ confirmPassword, password }, ctx) => {
if (confirmPassword !== password) {
ctx.addIssue({
code: 'custom',
message: 'The passwords did not match',
path: ['confirmPassword'], // Field to attach the error to
});
}
});
type PasswordChangeForm = z.infer<typeof PasswordChangeSchema>;
Функція `superRefine` отримує повністю проаналізований об’єкт і контекст (`ctx`). Ви можете додавати спеціальні проблеми до певних полів, надаючи вам повний контроль над складними бізнес-правилами з кількома полями.
Патерн 4: Перетворення та приведення даних
Форми в Інтернеті працюють із рядками. Користувач, який вводить «25» в ``, все одно видає значення рядка. Ваша схема має відповідати за перетворення цього необробленого введення в чисті дані з правильним типом, які потрібні вашому додатку.
import { z } from 'zod';
const EventCreationSchema = z.object({
eventName: z.string().trim().min(1), // Trim whitespace before validation
// Coerce a string from an input into a number
capacity: z.coerce.number().int().positive('Capacity must be a positive number.'),
// Coerce a string from a date input into a Date object
startDate: z.coerce.date(),
// Transform input into a more useful format
tags: z.string().transform(val =>
val.split(',').map(tag => tag.trim())
), // e.g., "tech, global, conference" -> ["tech", "global", "conference"]
});
type EventData = z.infer<typeof EventCreationSchema>;
Ось що відбувається:
- `.trim()`: Просте, але потужне перетворення, яке очищає вхідні дані рядка.
- `z.coerce`: це спеціальна функція Zod, яка спочатку намагається привести введені дані до вказаного типу (наприклад, `"123"` до `123`), а потім запускає валідації. Це важливо для обробки необроблених даних форми.
- `.transform()`: Для більш складної логіки `.transform()` дозволяє запустити функцію для значення після того, як воно було успішно перевірено, змінюючи його на більш бажаний формат для логіки вашого додатка.
Інтеграція з бібліотеками форм: Практичне застосування
Визначення схеми — це лише половина справи. Щоб бути справді корисним, він має бездоганно інтегруватися з бібліотекою керування формами вашого інтерфейсу. Більшість сучасних бібліотек форм, таких як React Hook Form, VeeValidate (для Vue) або Formik, підтримують це за допомогою концепції, яка називається «resolver».
Давайте розглянемо приклад із використанням React Hook Form і офіційного Zod resolver.
// 1. Install necessary packages
// npm install react-hook-form zod @hookform/resolvers
import React from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 2. Define our schema (same as before)
const UserProfileSchema = z.object({
username: z.string().min(3, "Username is too short"),
email: z.string().email(),
});
// 3. Infer the type
type UserProfile = z.infer<typeof UserProfileSchema>;
// 4. Create the React Component
export const ProfileForm = () => {
const {
register,
handleSubmit,
formState: { errors }
} = useForm<UserProfile>({ // Pass the inferred type to useForm
resolver: zodResolver(UserProfileSchema), // Connect Zod to React Hook Form
});
const onSubmit = (data: UserProfile) => {
// 'data' is fully typed and guaranteed to be valid!
console.log('Valid data submitted:', data);
// e.g., call an API with this clean data
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="username">Username</label>
<input id="username" {...register('username')} />
{errors.username && <p style={{color: 'red'}}>{errors.username.message}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" {...register('email')} />
{errors.email && <p style={{color: 'red'}}>{errors.email.message}</p>}
</div>
<button type="submit">Submit</button>
</form>
);
};
Це чудово елегантна та надійна система. `zodResolver` діє як міст. React Hook Form делегує весь процес валідації Zod. Якщо дані дійсні відповідно до `UserProfileSchema`, викликається функція `onSubmit` з чистими, типізованими та, можливо, перетвореними даними. Якщо ні, об’єкт `errors` заповнюється точними повідомленнями, які ми визначили в нашій схемі.
Поза інтерфейсом: Type Safety повного стеку
Справжня потужність цього патерну реалізується, коли ви поширюєте його на весь свій технологічний стек. Оскільки ваша схема Zod — це лише об’єкт JavaScript/TypeScript, її можна спільно використовувати між кодом інтерфейсу та серверної частини.
Спільне джерело правди
У сучасній налаштуванні монорепозиторію (з використанням таких інструментів, як Turborepo, Nx або навіть просто Yarn/NPM workspaces) ви можете визначити свої схеми в спільному пакеті `common` або `core`.
/my-project ├── packages/ │ ├── common/ # <-- Shared code │ │ └── src/ │ │ └── schemas/ │ │ └── user-profile.ts (exports UserProfileSchema) │ ├── web-app/ # <-- Frontend (e.g., Next.js, React) │ └── api-server/ # <-- Backend (e.g., Express, NestJS)
Тепер і зовнішній, і внутрішній інтерфейс можуть імпортувати той самий об’єкт `UserProfileSchema`.
- The Frontend використовує його з `zodResolver`, як показано вище.
- The Backend використовує його в кінцевій точці API для перевірки тіл вхідних запитів.
// Example of a backend Express.js route
import express from 'express';
import { UserProfileSchema } from 'common/src/schemas/user-profile'; // Import from shared package
const app = express();
app.use(express.json());
app.post('/api/profile', (req, res) => {
const validationResult = UserProfileSchema.safeParse(req.body);
if (!validationResult.success) {
// If validation fails, return a 400 Bad Request with the errors
return res.status(400).json({ errors: validationResult.error.flatten() });
}
// If we reach here, validationResult.data is fully typed and safe to use
const cleanData = validationResult.data;
// ... proceed with database operations, etc.
console.log('Received safe data on server:', cleanData);
return res.status(200).json({ message: 'Profile updated!' });
});
Це створює непорушний контракт між вашим клієнтом і сервером. Ви досягли справжньої наскрізної type safety. Тепер неможливо, щоб інтерфейс надіслав форму даних, яку не очікує серверна частина, оскільки обидва перевіряють на відповідність точному визначенню.
Розширені міркування для глобальної аудиторії
Створення програм для міжнародної аудиторії вносить додаткову складність. Type-safe підхід за схемою забезпечує чудову основу для вирішення цих проблем.
Локалізація (i18n) повідомлень про помилки
Жорстке кодування повідомлень про помилки англійською мовою неприйнятно для глобального продукту. Ваша схема валідації повинна підтримувати інтернаціоналізацію. Zod дозволяє надавати спеціальну карту помилок, яку можна інтегрувати зі стандартною бібліотекою i18n, як-от `i18next`.
import { z, ZodErrorMap } from 'zod';
import i18next from 'i18next'; // Your i18n instance
// This function maps Zod issue codes to your translation keys
const zodI18nMap: ZodErrorMap = (issue, ctx) => {
let message;
// Example: translate 'invalid_type' error
if (issue.code === 'invalid_type') {
message = i18next.t('validation.invalid_type');
}
// Add more mappings for other issue codes like 'too_small', 'invalid_string' etc.
else {
message = ctx.defaultError; // Fallback to Zod's default
}
return { message };
};
// Set the global error map for your application
z.setErrorMap(zodI18nMap);
// Now, all schemas will use this map to generate error messages
const MySchema = z.object({ name: z.string() });
// MySchema.parse(123) will now produce a translated error message!
Встановивши глобальну карту помилок у точці входу вашої програми, ви можете переконатися, що всі повідомлення валідації передаються через вашу систему перекладу, забезпечуючи безперебійну роботу для користувачів у всьому світі.
Створення багаторазових спеціальних валідацій
Різні регіони мають різні формати даних (наприклад, номери телефонів, податкові ідентифікатори, поштові індекси). Ви можете інкапсулювати цю логіку в багаторазові вдосконалення схеми.
import { z } from 'zod';
import { isValidPhoneNumber } from 'libphonenumber-js'; // A popular library for this
// Create a reusable custom validation for international phone numbers
const internationalPhoneNumber = z.string().refine(
(phone) => isValidPhoneNumber(phone),
{
message: 'Please provide a valid international phone number.',
}
);
// Now use it in any schema
const ContactSchema = z.object({
name: z.string(),
phone: internationalPhoneNumber,
});
Цей підхід зберігає ваші схеми чистими, а складну, специфічну для регіону логіку валідації централізованою та багаторазовою.
Висновок: будуйте з упевненістю
Перехід від фрагментованої, імперативної валідації до уніфікованого підходу за схемою є трансформаційним. Встановивши єдине джерело правди для форми та правил ваших даних, ви усуваєте цілі категорії помилок, підвищуєте продуктивність розробників і створюєте більш стійку та зручну в обслуговуванні кодову базу.
Давайте підсумуємо глибокі переваги:
- Надійність: Ваші форми стають більш передбачуваними та менш схильними до помилок під час виконання.
- Зручність обслуговування: Логіка централізована, декларативна та проста для розуміння.
- Досвід розробника: Насолоджуйтесь статичним аналізом, автозаповненням і впевненістю, що ваші типи та валідація завжди синхронізовані.
- Цілісність повного стеку: Обмінюйтеся схемами між клієнтом і сервером, щоб створити справді непорушний контракт даних.
Інтернет продовжуватиме розвиватися, але потреба в надійному обміні даними між користувачами та системами залишатиметься постійною. Застосування type-safe валідації форм на основі схеми — це не просто слідування новій тенденції; це про прийняття більш професійного, дисциплінованого та ефективного способу створення програмного забезпечення. Отже, наступного разу, коли ви почнете новий проект або рефакторинг старої форми, я закликаю вас звернутися до такої бібліотеки, як Zod, і побудувати свій фундамент на впевненості єдиної уніфікованої схеми. Ваше майбутнє «я» — і ваші користувачі — подякують вам.